Skip to content

Sidebar: hop selectedTabId row delivery to RunLoop.main + guard synchronous row onReceive - #7341

Closed
gadgetsam wants to merge 2 commits into
manaflow-ai:mainfrom
gadgetsam:fix-sidebar-row-sync-publisher-delivery
Closed

gadgetsam wants to merge 2 commits into
manaflow-ai:mainfrom
gadgetsam:fix-sidebar-row-sync-publisher-delivery

Conversation

@gadgetsam

@gadgetsam gadgetsam commented Jul 4, 2026 •

Copy link
Copy Markdown

Summary

Problem. Stable v0.64.17 livelocks in the wild with the #2586 signature. On my machine (0.64.17 (97), macOS 26.5, ~20–40 workspaces driving per-second tmux title updates) macOS wrote three .hang reports in two days — 173 s, 620 s, and one 62-minute hang force-quit from the Dock. Two live sample captures taken 83 s apart during a fourth hang are statistically identical: 6586/6586 and 3178/3178 main-thread samples inside one never-returning NSHostingView.beginTransaction → GraphHost.flushTransactions, ~90 % under AG::Subgraph::update doing LazySubviewPlacements.placeSubviews → LazyStack.place → ForEachList.applyNodes over the workspace-list ForEach, plus ~9 % applying LazyLayoutCacheItem.AllItemsPhaseMutation — the re-enqueue that keeps the flush from draining. 3–27 TerminalController.v2MainSync socket threads sit turnstile-blocked behind the busy main thread, so the CLI and Claude hooks hang in read() too. I can attach the .hang files and samples (14–16 MB each) to #2586.

This is not a re-fix of #7117 — I'm aware #7117 removed the v0.64.17 row-height probes and hover tracker one day after the release was cut, and #7221 added the scale gate. This PR closes the one residual synchronous-delivery channel that survived, and bans the shape in CI:

Root cause (residual). TabItemView's .onReceive(tabManager.selectedTabIdPublisher …) is the only row subscription without a .receive(on:) hop (both sibling publishers at the same call site have one). The publisher is a CurrentValueSubject bridge: it replays synchronously at subscribe time — and a lazy row subscribes while the LazyVStack realizes it, inside an in-flight layout transaction — and it emits during selectedTabId's willSet, so a selection change made mid-update delivers mid-update. Either path writes the row's @State observedIsActive inside the transaction being laid out: the same write-during-layout family as #2586/#6556.

Fix. Route the chain through .receive(on: RunLoop.main), matching its two siblings. No visual change: first render reads the live fallback (observedIsActive ?? (tabManager.selectedTabId == tab.id)), row onAppear seeds the same value, and the deferred replay is deduplicated by updateObservedActiveState's equality guard.

Guard. scripts/check-sidebar-lazy-layout.py now fails on any row-region .onReceive( whose publisher argument lacks .receive(on: (balanced-paren extraction, so a hop inside the action closure can't mask a synchronous publisher). Two new meta-test cases (o)/(p) prove catch + pass, including a multi-line .receive(\n on:…).

Two-commit red/green (per repo policy): commit 1 adds the guard + meta-tests only — check-sidebar-lazy-layout.py goes red on TabItemView (and meta-case (a) with it, by design); commit 2 fixes the call site — everything green.

Refs #2586 (evidence lineage: #5323 → #5764 → #5845 → #6033/#6210 → #6556/#7117/#7221).

Testing

python3 scripts/check-sidebar-lazy-layout.py        # exit 1 at commit 1 (TabItemView flagged); ok at HEAD
python3 tests/test_ci_sidebar_lazy_layout_guard.py  # 22/22 PASS at HEAD
python3 scripts/swift_file_length_budget.py         # budget respected (ContentView 16427 → 16434, +7)
git diff --check                                    # clean

Honest scale/build note: I'm an external contributor without Xcode on this machine, so I could not run reload.sh or SidebarLazyLayoutScaleTests locally — requesting CI as the build/behavior gate. The Swift diff is a single chained operator plus a constraint comment, shape-identical to the sibling subscription at the same call site. Localization audit: no user-facing strings changed.

Demo Video

Not applicable — no visual behavior change; this hardens delivery timing for a captured live hang (sample excerpts above, full captures available on request). #7117 precedent: this class is "not cleanly unit-testable without an on-device sample".

Review Trigger

Will post @codex review / @coderabbitai review / @greptile-apps review / @cubic-dev-ai review as a comment after the latest commit.

Checklist

  • Tested locally (guard red/green, meta-tests 22/22, budget, whitespace — static; no Xcode available)
  • Added/updated tests (guard meta-test cases (o)/(p))
  • Docs/changelog (n/a — changelog regenerated at release)
  • Bot reviews requested (comment after open)
  • All bot + human comments resolved

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes a sidebar livelock by deferring row selection updates to RunLoop.main and adds a CI guard to block synchronous onReceive delivery in row views. Prevents hangs caused by selectedTabIdPublisher delivering during layout.

  • Bug Fixes
    • In TabItemView, route tabManager.selectedTabIdPublisher through .receive(on: RunLoop.main) to avoid synchronous onReceive updates during layout. No visual change; first render uses the existing fallback and updates are deduped.
    • Add a CI guard that fails on any row .onReceive( whose publisher lacks .receive(on:), plus tests (including multiline .receive(on:)) to catch and prevent regressions.

Written for commit 2d87b47. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved sidebar/tab selection updates to behave more reliably during fast UI updates, reducing the risk of layout-related glitches or freezes.
    • Fixed handling of row-based event updates so they are delivered safely on the main thread.
  • Tests

    • Added coverage for row update scenarios to verify both failing and passing behavior with deferred event delivery.

gadgetsam and others added 2 commits July 4, 2026 08:41
A row subscribing a Combine publisher without a .receive(on:) hop gets the
CurrentValueSubject bridge's synchronous subscribe-time replay while the
LazyVStack is realizing the row — inside an in-flight SwiftUI layout
transaction — and its willSet-time emission delivers mid-update. Either
path lets the onReceive action write row @State inside the transaction
being laid out: the manaflow-ai#2586/manaflow-ai#6556 write-during-layout livelock family.

Stable v0.64.17 shipped exactly this shape in TabItemView via
tabManager.selectedTabIdPublisher; the guard goes red on this commit by
design (two-commit red/green policy) and the fix lands in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TabItemView's .onReceive of tabManager.selectedTabIdPublisher was the one
row subscription without a .receive(on:) hop (its two sibling publishers
below it both have one). The publisher is a CurrentValueSubject bridge, so
it replays the current value synchronously at subscribe time — and a lazy
sidebar row subscribes while the LazyVStack realizes it, inside an
in-flight SwiftUI layout transaction — and it emits during selectedTabId's
willSet, so a selection change made mid-update also delivers mid-update.
Either path writes the row's @State observedIsActive inside the
transaction being laid out: the manaflow-ai#2586/manaflow-ai#6556 write-during-layout family
that livelocked stable v0.64.17 in the wild (62-minute flushTransactions
hang, force-quit; see PR body for captures).

Delivering on RunLoop.main keeps the write out of the transaction. No
visual change: first render reads the live fallback
(observedIsActive ?? (tabManager.selectedTabId == tab.id)) and row
onAppear seeds the same value, so the deferred replay is deduplicated by
updateObservedActiveState's equality guard.

ContentView.swift grows by 7 lines (operator + constraint comment);
budget updated 16427 -> 16434 to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

@gadgetsam is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@gadgetsam

Copy link
Copy Markdown
Author

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@coderabbitai

coderabbitai Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f81cd6d9-f2c8-43fc-9ed7-3e0806cc4c78

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a .receive(on: RunLoop.main) fix to a Combine pipeline in ContentView.swift to avoid livelock during SwiftUI layout, and extends the sidebar lazy-layout CI guard script to detect synchronous .onReceive usage lacking a .receive(on:) hop, with corresponding test fixtures.

Changes

Sync onReceive Fix and Lint Guard

Layer / File(s) Summary
ContentView Combine pipeline fix
Sources/ContentView.swift
Adds .receive(on: RunLoop.main) with documentation on synchronous replay timing during layout to avoid livelock-related state updates.
Guard script detection logic and wiring
scripts/check-sidebar-lazy-layout.py
Adds ONRECEIVE_CALL regex, ROW_SYNC_ONRECEIVE_MESSAGE, and find_sync_onreceive(region) helper; wires the check into row-wrapper scanning and per-row-view type scanning for GUARDED_ROW_TYPES.
Test fixtures and documentation for new guard cases
tests/test_ci_sidebar_lazy_layout_guard.py
Extends docstring case list with (o)/(p) entries and adds sync_onreceive_row (expected fail) and deferred_onreceive_row (expected pass) fixtures.

Estimated code review effort: 2 (Simple) | ~12 minutes

Possibly related PRs

  • manaflow-ai/cmux#7221: Both PRs modify scripts/check-sidebar-lazy-layout.py to extend the sidebar row-view lazy-layout guard's scanning logic for guarded row-view types.
🚥 Pre-merge checks | ✅ 25
✅ Passed checks (25 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: deferring selectedTabId row delivery and guarding synchronous row onReceive.
Description check ✅ Passed It includes the required sections and enough detail on what changed, why, testing, demo status, and checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PASS — the only Swift change is a TabItemView .receive(on: RunLoop.main) hop in a SwiftUI view; it adds no new actor-isolation debt.
Cmux Swift Blocking Runtime ✅ Passed Adds only a documented RunLoop hop in a passive onReceive path; no semaphores, waits, sleeps, syncs, or locks were introduced.
Cmux Browser Automation Off-Main ✅ Passed PR only changes sidebar layout/guard files; no browser.* routing, WebKit wait paths, or socket-worker policy tests were modified.
Cmux Expensive Synchronous Load ✅ Passed Diff only adds .receive(on: RunLoop.main) to a tab-selection onReceive and a line-budget tweak; no agent-history loads or heavy sync parsers were introduced.
Cmux Cache Substitution Correctness ✅ Passed The Swift change only defers row delivery with .receive(on: RunLoop.main); it doesn't replace any authoritative persistence/history/snapshot read, and the live selectedTabId fallback remains.
Cmux No Hacky Sleeps ✅ Passed The PR adds only structural Swift/Python guard changes and deterministic tests; no new fixed sleeps, timers, polling, or delayed dispatch appear in the changed non-Swift code.
Cmux Algorithmic Complexity ✅ Passed Diff adds only a .receive(on: RunLoop.main) hop to a TabItemView Combine chain; no new loops, rescans, sorts, or collection-wide work in hot paths.
Cmux Swift Concurrency ✅ Passed PASS: The Swift diff only adds a RunLoop.main hop to an existing onReceive pipeline in a SwiftUI row; no new background queues, fire-and-forget Tasks, or new app-state Combine introduced.
Cmux Swift @Concurrent ✅ Passed Swift diff only adds .receive(on: RunLoop.main) to a UI-bound onReceive; no @concurrent, nonisolated async, or heavy async helper changes were introduced.
Cmux Swift File And Package Boundaries ✅ Passed PASS: The change is a 7-line timing fix in an already-16k-line app-target SwiftUI row, with no new mixed responsibility or package-boundary regression.
Cmux Swiftpm Lockfiles ✅ Passed Only Sources/ContentView.swift and .github/swift-file-length-budget.tsv changed; no Package.swift, Package.resolved, .gitignore, workflow, or Xcode project files were touched.
Cmux Swift Logging ✅ Passed Diff only adds .receive(on: RunLoop.main) and comments in ContentView; no print/debugPrint/NSLog/Logger or secret-bearing logging changes.
Cmux User-Facing Error Privacy ✅ Passed PASS: The diff only adds a Combine scheduler hop plus developer-only comments/CI guard/test messaging; no user-facing errors, alerts, API bodies, or recovery copy changed.
Cmux Full Internationalization ✅ Passed Diff only adds a developer comment and RunLoop.main hop in ContentView plus a budget tweak; no user-facing text or locale assets changed.
Cmux Swiftui State Layout ✅ Passed The only SwiftUI source change adds .receive(on: RunLoop.main) to an existing row onReceive; no new ObservableObject/@published, GeometryReader, lazy-row store refs, or render-time state writes wer...
Cmux Architecture Rethink ✅ Passed Documented RunLoop hop is a local bridge, not a symptom patch; TabItemView still owns observedIsActive and first render falls back to selectedTabId, while guard/test changes are CI-only.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Only sidebar row delivery/guard logic changed; no standalone NSWindow/NSPanel/WindowGroup additions or cmuxAuxiliaryWindowIdentifiers edits, so the shortcut rule isn’t triggered.
Cmux Source Artifacts ✅ Passed Only intentional source/config files changed (.swift and budget TSV); no logs, temp dirs, build outputs, or other artifact paths appear in the diff.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Production diff in Sources/ContentView.swift adds only .receive(on: RunLoop.main) and comments; no new #if DEBUG/test-only accessor or debug seam appears in the added lines.
Cmux No Ambient Global State ✅ Passed Swift diff only adds .receive(on: RunLoop.main) inside TabItemView; no new file-scope funcs, vars, namespaces, or singletons were introduced.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 4, 2026

Copy link
Copy Markdown

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@gadgetsam I can't start this review because your workspace has reached its free monthly review limit. cubic has reviewed 241,631 of the 240,000 allowed lines of code this month. Reviews resume on 1 August 2026 (in 28 days). Paid plans include much higher monthly review limits. Upgrade now to resume reviews.

To help optimise your usage, you can tune cubic to get the most out of your usage limits:

Learn more →

@coderabbitai

coderabbitai Bot commented Jul 4, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

1 similar comment
@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_ci_sidebar_lazy_layout_guard.py`:
- Around line 27-33: The new onReceive test cases and their docstring entries
reuse case label "(o)", which collides with an existing unrelated case later in
the file. Update the identifiers for the new synchronous/deferred subscription
tests in the test class and matching docstring entries to the next unused
letters so each scenario has a unique cross-reference, keeping the labels in
sync with the existing whole-file row-wrapper scan case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 640d56b0-fa6c-4199-a3cb-513f1a852474

📥 Commits

Reviewing files that changed from the base of the PR and between f48922a and 2d87b47.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • scripts/check-sidebar-lazy-layout.py
  • tests/test_ci_sidebar_lazy_layout_guard.py

Comment on lines +27 to +33
(o) An `.onReceive(` in a row whose publisher lacks a `.receive(on:)` hop
fails — a CurrentValueSubject bridge replays synchronously while the
LazyVStack realizes the row (and emits during willSet), so the action
writes row @State inside the in-flight layout transaction. This exact
shape shipped in stable v0.64.17 via `selectedTabIdPublisher`.
(p) The same subscription routed through `.receive(on: RunLoop.main)`
passes, including when `.receive(on:)` spans multiple lines.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate case letter "(o)" reused for two different tests.

The new cases at lines 323-343 (and their docstring entries at lines 27-33) reuse label "(o)", which is already used later in the file (line 392: # (o) Whole-file row-wrapper scan...) for an unrelated pre-existing test. Two different test scenarios now share the same letter, making it harder to cross-reference a failing case back to the docstring.

Consider relettering the new sync/deferred onReceive cases (e.g., to the next unused letters) to avoid collision with the existing wrapper-scan case.

Also applies to: 323-343

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_ci_sidebar_lazy_layout_guard.py` around lines 27 - 33, The new
onReceive test cases and their docstring entries reuse case label "(o)", which
collides with an existing unrelated case later in the file. Update the
identifiers for the new synchronous/deferred subscription tests in the test
class and matching docstring entries to the next unused letters so each scenario
has a unique cross-reference, keeping the labels in sync with the existing
whole-file row-wrapper scan case.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a livelock family (#2586/#6556) by adding .receive(on: RunLoop.main) to the one remaining synchronous-delivery .onReceive( subscription in TabItemView (selectedTabIdPublisher), matching the two sibling subscriptions at the same call site. It also adds a CI guard (find_sync_onreceive) to check-sidebar-lazy-layout.py and two new meta-test cases (o)/(p) to prevent regression.

  • Swift fix: selectedTabIdPublisher's .onReceive( chain now hops through .receive(on: RunLoop.main), preventing the CurrentValueSubject synchronous replay (and willSet mid-update emission) from writing @State inside an in-flight LazyVStack layout transaction.
  • Guard: find_sync_onreceive uses balanced-paren extraction of the publisher argument to detect .onReceive( chains lacking .receive(on:, so a hop placed only in the action closure cannot mask a synchronous publisher.
  • Budget: ContentView.swift line budget bumped by 7 (16427 → 16434) to account for the inline comment block.

Confidence Score: 4/5

The Swift change is a one-operator addition that aligns the last unguarded subscription with its two already-fixed siblings; the guard script and meta-tests are additive and do not touch any production runtime path.

The fix is minimal, well-motivated, and consistent with the existing pattern at the same call site. The two observations (an unverified test assertion about action-closure masking, and a theoretical false-positive from unbalanced parens in malformed Swift) are both in the guard tooling, not in the production Swift path, and neither affects runtime correctness.

The guard script (scripts/check-sidebar-lazy-layout.py) and its meta-test (tests/test_ci_sidebar_lazy_layout_guard.py) are the only files worth a second look — specifically the action-closure masking claim and the unbalanced-paren edge case in find_sync_onreceive. Sources/ContentView.swift itself is straightforward.

Important Files Changed

Filename Overview
Sources/ContentView.swift Adds .receive(on: RunLoop.main) to the selectedTabIdPublisher subscription in TabItemView, harmonizing it with its two sibling subscriptions. The 7-line delta is a comment block plus the operator; the fix is minimal and correct.
scripts/check-sidebar-lazy-layout.py Adds find_sync_onreceive that scans .onReceive( publisher arguments via balanced-paren extraction; both call sites pass neutralized Swift, so comment/string contents can't produce false positives. Minor: the function docstring claims to guard against action-closure masking but the meta-test case (o) doesn't include a fixture where the action has .receive(on:) to exercise that specific guarantee.
tests/test_ci_sidebar_lazy_layout_guard.py Adds test cases (o) and (p) covering the failing and passing sides of the new onReceive guard. Case (o) covers missing .receive(on:), case (p) covers multi-line .receive(\n on:...). The claimed "action-closure cannot mask" property is asserted in the docstring but has no corresponding fixture.
.github/swift-file-length-budget.tsv Budget for ContentView.swift bumped by 7 lines to match the comment block added with the fix. Mechanical and accurate.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant LVS as LazyVStack (layout tx)
    participant TIV as TabItemView
    participant CVS as CurrentValueSubject
    participant RLM as RunLoop.main

    note over LVS,TIV: Row realization (in-flight layout transaction)
    LVS->>TIV: realize row (subscribe)

    alt BEFORE fix — synchronous delivery
        TIV->>CVS: subscribe
        CVS-->>TIV: replay current value SYNCHRONOUSLY
        TIV->>TIV: "@State write mid-transaction"
        note over TIV: write-during-layout livelock #2586
    end

    alt AFTER fix — delivery hopped to RunLoop.main
        TIV->>CVS: subscribe + .receive(on: RunLoop.main)
        CVS-->>RLM: enqueue delivery (deferred)
        note over LVS: layout transaction completes
        RLM-->>TIV: deliver isSelected (next cycle)
        TIV->>TIV: "@State write — safe"
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant LVS as LazyVStack (layout tx)
    participant TIV as TabItemView
    participant CVS as CurrentValueSubject
    participant RLM as RunLoop.main

    note over LVS,TIV: Row realization (in-flight layout transaction)
    LVS->>TIV: realize row (subscribe)

    alt BEFORE fix — synchronous delivery
        TIV->>CVS: subscribe
        CVS-->>TIV: replay current value SYNCHRONOUSLY
        TIV->>TIV: "@State write mid-transaction"
        note over TIV: write-during-layout livelock #2586
    end

    alt AFTER fix — delivery hopped to RunLoop.main
        TIV->>CVS: subscribe + .receive(on: RunLoop.main)
        CVS-->>RLM: enqueue delivery (deferred)
        note over LVS: layout transaction completes
        RLM-->>TIV: deliver isSelected (next cycle)
        TIV->>TIV: "@State write — safe"
    end
Loading

Reviews (1): Last reviewed commit: "Sidebar: hop selectedTabId row delivery ..." | Re-trigger Greptile

Comment on lines +323 to +347
# (o) An .onReceive( whose publisher chain has no .receive(on:) hop:
# the CurrentValueSubject bridge replays synchronously during lazy row
# realization and emits during willSet, so the action's @State write
# lands inside the in-flight layout transaction (the #2586/#6556
# family). This shape shipped in stable v0.64.17 and livelocked in the
# wild on 2026-07-02/03. A .receive(on:) inside the ACTION closure
# (outside the publisher argument) must not mask the violation.
sync_onreceive_row = row_fixture(
" HStack { Text(tab.title) }\n"
" .onReceive(\n"
" tabManager.selectedTabIdPublisher\n"
" .map { $0 == tab.id }\n"
" .removeDuplicates()\n"
" ) { isSelected in\n"
" observedIsActive = isSelected\n"
" }"
)
failures += 0 if expect(
run_guard(write_fixture(workdir, "SyncOnReceiveRow.swift", sync_onreceive_row)),
False, "row .onReceive without .receive(on:) fails",
) else 1

# (p) The same subscription with a .receive(on: RunLoop.main) hop in
# the publisher chain passes -- also with the hop split across lines,
# since the real call sites chain one operator per line.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test claim for action-closure masking is unverified

The case (o) comment states "A .receive(on:) inside the ACTION closure (outside the publisher argument) must not mask the violation," and the guard's docstring (find_sync_onreceive) makes the same guarantee. However, the sync_onreceive_row fixture has no .receive(on:) anywhere — not in the publisher, not in the action — so the fixture only proves the basic "missing hop fails" path and does not actually exercise the masking scenario. A future regression where the balanced-paren scan accidentally consumed the action closure would silently pass this test. Adding a second fixture that places .receive(on: RunLoop.main) inside the action body (but not the publisher) and asserts expect(..., False, ...) would close this gap.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +165 to +190
def find_sync_onreceive(region):
"""Return True if ``region`` (neutralized Swift) contains an
``.onReceive(`` whose publisher argument lacks a ``.receive(on:`` hop.

The publisher expression is the balanced-parenthesis argument list of the
``.onReceive(`` call; the action trailing closure sits outside it, so a
``.receive(on:)`` inside the action cannot mask a synchronous publisher.
"""
for match in ONRECEIVE_CALL.finditer(region):
i = match.end() - 1 # at the opening '(' of the argument list
depth = 0
start = i
n = len(region)
while i < n:
ch = region[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
break
i += 1
publisher_expr = re.sub(r"\s+", "", region[start:i + 1])
if ".receive(on:" not in publisher_expr:
return True
return False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unmatched parenthesis silently short-circuits the check

If region contains an .onReceive( whose opening ( is never balanced — for example because a macro or string interpolation produced a lone ( that the neutralizer didn't collapse — the while i < n loop exits with depth > 0 (without hitting the break). At that point i == n, so publisher_expr = re.sub(r"\s+", "", region[start:n+1]) is effectively the rest of the file from the ( onward. That's unlikely to contain .receive(on:, so the function would incorrectly return True and emit a false positive on otherwise clean code. A small guard like if i >= n: continue (skipping unbalanced matches) before the publisher_expr extraction would make the failure mode more predictable, though this scenario would only arise from malformed Swift that the compiler would also reject.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a live hang in stable v0.64.17 by routing TabItemView's selectedTabIdPublisher subscription through .receive(on: RunLoop.main), matching the two sibling publishers at the same call site that already had the hop. It also extends check-sidebar-lazy-layout.py with a balanced-paren guard (find_sync_onreceive) that fails CI whenever a row-region .onReceive( publisher argument lacks a .receive(on:) hop.

  • Swift fix (ContentView.swift): A single chained operator added to the CurrentValueSubject bridge; first render is covered by the existing observedIsActive ?? (tabManager.selectedTabId == tab.id) live fallback, and updateObservedActiveState's equality guard deduplicates the deferred replay.
  • Guard (check-sidebar-lazy-layout.py): find_sync_onreceive uses paren-depth walking to extract only the publisher argument (not the trailing action closure), correctly preventing a .receive(on:) placed inside the action from masking a synchronous publisher. The check is wired into both the type-body path and the scan_all_rows path.
  • Meta-tests (test_ci_sidebar_lazy_layout_guard.py): Cases (o) and (p) cover the basic pass/fail scenarios; a stated bypass scenario (.receive(on:) inside the action closure) is described in comments but not exercised by any fixture, and the scan_all_rows branch of the new check has no corresponding meta-test.

Confidence Score: 4/5

The Swift change is a single chained operator on an existing subscription, safe to merge; the guard and tests are sound with two small coverage gaps in the test harness.

The production Swift diff is minimal and correct — one .receive(on: RunLoop.main) added to match the two sibling publishers at the same call site, with a live fallback covering first render. The guard logic (balanced-paren extraction) is well-designed and handles multi-line operators. The two findings are both in the test harness: the 'hop in action' bypass scenario claimed in the case (o) comment isn't actually exercised by the fixture, and the scan_all_rows branch of the new check has no meta-test. Neither gap creates a production risk today.

tests/test_ci_sidebar_lazy_layout_guard.py — test case (o) comment overstates what the fixture proves, and there is no test for the scan_all_rows branch of the new guard.

Important Files Changed

Filename Overview
Sources/ContentView.swift Adds .receive(on: RunLoop.main) to selectedTabIdPublisher in TabItemView, matching its two sibling subscriptions; change is minimal and correctly uses the observedIsActive fallback for first render.
scripts/check-sidebar-lazy-layout.py Adds find_sync_onreceive() guard that uses balanced-paren extraction to detect .onReceive( calls lacking a .receive(on:) hop in row views; logic is correct but one edge-case path (scan_all_rows branch) has no corresponding meta-test.
tests/test_ci_sidebar_lazy_layout_guard.py Adds meta-test cases (o) and (p) for the new .onReceive guard; cases exercise the type-body extraction path but do not cover the scan_all_rows branch or the described "hop inside action closure" bypass scenario.
.github/swift-file-length-budget.tsv Bumps ContentView.swift line budget from 16427 to 16434 (+7) to account for the new .receive(on: RunLoop.main) chain and its comment block; well within repo policy.

Reviews (2): Last reviewed commit: "Sidebar: hop selectedTabId row delivery ..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds .receive(on: RunLoop.main) to the selectedTabIdPublisher chain inside TabItemView, closing the last synchronous-delivery path that survived the #7117/7221 fixes and which livelocked in the wild on stable v0.64.17. A new find_sync_onreceive CI guard in check-sidebar-lazy-layout.py rejects any row-region .onReceive( whose publisher argument lacks a .receive(on: hop, using balanced-paren extraction so a hop inside the action closure cannot mask the check.

  • Sources/ContentView.swift: single chained operator (.receive(on: RunLoop.main)) added to match the two sibling publishers at the same call site; onAppear seeds observedIsActive synchronously so the deferred replay is a no-op, and the equality guard in updateObservedActiveState deduplicates it.
  • scripts/check-sidebar-lazy-layout.py: find_sync_onreceive scans the balanced-paren publisher argument of each .onReceive( in guarded row regions and fails if .receive(on: is absent; applied to both scan_all_rows (file-level) and per-type-body paths.
  • tests/test_ci_sidebar_lazy_layout_guard.py: cases (o) and (p) exercise the new guard — fail without hop, pass with single-line and multi-line .receive(on:) — though the action-masking guarantee called out in the (o) comment has no explicit fixture."

Confidence Score: 4/5

Safe to merge; the Swift change is a single chained operator matching the existing pattern at the same call site, and the guard addition has no production runtime impact.

The Swift fix is minimal, well-motivated by captured hang samples, and consistent with both sibling publishers. The guard script logic is sound — balanced-paren extraction correctly isolates the publisher argument from the action closure. The only gap is that test case (o)'s stated masking guarantee (.receive(on:) in the action cannot hide a synchronous publisher) is described but not covered by a concrete fixture; the algorithm is correct, but the property is untested.

tests/test_ci_sidebar_lazy_layout_guard.py — the action-masking property asserted in the (o) comment should have a companion fixture to remain a live test contract.

Important Files Changed

Filename Overview
Sources/ContentView.swift Adds .receive(on: RunLoop.main) to selectedTabIdPublisher chain in TabItemView, matching its two sibling publishers and preventing @State writes during in-flight layout transactions.
scripts/check-sidebar-lazy-layout.py Adds find_sync_onreceive guard that extracts balanced-paren publisher argument from .onReceive( calls and rejects any lacking .receive(on:; logic is correct but the action-masking property described in the comment has no explicit test.
tests/test_ci_sidebar_lazy_layout_guard.py Adds test cases (o) and (p) for the new sync-onReceive guard; case (o) comment promises to verify that .receive(on:) in the action closure can't mask the violation, but no such fixture is included.
.github/swift-file-length-budget.tsv Budget entry for ContentView.swift incremented by 7 lines (16427→16434), correctly accounting for the .receive(on:) operator and its accompanying comment block.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant LS as LazyVStack (layout tx)
    participant TIV as TabItemView (row)
    participant CSP as selectedTabIdPublisher (CurrentValueSubject)
    participant RL as RunLoop.main

    Note over LS,TIV: Row realization during layout transaction
    LS->>TIV: realize row (subscribe)
    TIV->>CSP: .onReceive subscribe

    alt Before fix (synchronous replay)
        CSP-->>TIV: replays current value SYNCHRONOUSLY
        TIV-->>TIV: "@State write INSIDE layout transaction"
        Note over LS,TIV: write-during-layout livelock (#2586)
    end

    alt After fix (.receive(on: RunLoop.main))
        CSP-->>RL: value deferred to RunLoop.main
        Note over LS,TIV: layout transaction completes safely
        RL-->>TIV: deliver value on next RunLoop cycle
        TIV-->>TIV: "@State write OUTSIDE layout transaction"
    end

    Note over TIV: onAppear seeds observedIsActive synchronously
    Note over TIV: updateObservedActiveState equality guard deduplicates replay
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant LS as LazyVStack (layout tx)
    participant TIV as TabItemView (row)
    participant CSP as selectedTabIdPublisher (CurrentValueSubject)
    participant RL as RunLoop.main

    Note over LS,TIV: Row realization during layout transaction
    LS->>TIV: realize row (subscribe)
    TIV->>CSP: .onReceive subscribe

    alt Before fix (synchronous replay)
        CSP-->>TIV: replays current value SYNCHRONOUSLY
        TIV-->>TIV: "@State write INSIDE layout transaction"
        Note over LS,TIV: write-during-layout livelock (#2586)
    end

    alt After fix (.receive(on: RunLoop.main))
        CSP-->>RL: value deferred to RunLoop.main
        Note over LS,TIV: layout transaction completes safely
        RL-->>TIV: deliver value on next RunLoop cycle
        TIV-->>TIV: "@State write OUTSIDE layout transaction"
    end

    Note over TIV: onAppear seeds observedIsActive synchronously
    Note over TIV: updateObservedActiveState equality guard deduplicates replay
Loading

Reviews (2): Last reviewed commit: "Sidebar: hop selectedTabId row delivery ..." | Re-trigger Greptile

Comment on lines +323 to +343
# (o) An .onReceive( whose publisher chain has no .receive(on:) hop:
# the CurrentValueSubject bridge replays synchronously during lazy row
# realization and emits during willSet, so the action's @State write
# lands inside the in-flight layout transaction (the #2586/#6556
# family). This shape shipped in stable v0.64.17 and livelocked in the
# wild on 2026-07-02/03. A .receive(on:) inside the ACTION closure
# (outside the publisher argument) must not mask the violation.
sync_onreceive_row = row_fixture(
" HStack { Text(tab.title) }\n"
" .onReceive(\n"
" tabManager.selectedTabIdPublisher\n"
" .map { $0 == tab.id }\n"
" .removeDuplicates()\n"
" ) { isSelected in\n"
" observedIsActive = isSelected\n"
" }"
)
failures += 0 if expect(
run_guard(write_fixture(workdir, "SyncOnReceiveRow.swift", sync_onreceive_row)),
False, "row .onReceive without .receive(on:) fails",
) else 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test case (o) doesn't exercise its stated bypass scenario

The comment asserts that "A .receive(on:) inside the ACTION closure (outside the publisher argument) must not mask the violation," but the fixture has no .receive(on:) in the action at all — it only tests the straightforward "no hop anywhere" path. The balanced-paren extraction is what prevents the bypass, but that specific property is never actually exercised by this fixture. A supplementary sub-test that places .receive(on: RunLoop.main) inside the trailing closure body (e.g., Just(isSelected).receive(on: RunLoop.main).sink { … }) while keeping the publisher argument unhopped would verify the claim and protect it against future refactors of find_sync_onreceive.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +323 to +364
# (o) An .onReceive( whose publisher chain has no .receive(on:) hop:
# the CurrentValueSubject bridge replays synchronously during lazy row
# realization and emits during willSet, so the action's @State write
# lands inside the in-flight layout transaction (the #2586/#6556
# family). This shape shipped in stable v0.64.17 and livelocked in the
# wild on 2026-07-02/03. A .receive(on:) inside the ACTION closure
# (outside the publisher argument) must not mask the violation.
sync_onreceive_row = row_fixture(
" HStack { Text(tab.title) }\n"
" .onReceive(\n"
" tabManager.selectedTabIdPublisher\n"
" .map { $0 == tab.id }\n"
" .removeDuplicates()\n"
" ) { isSelected in\n"
" observedIsActive = isSelected\n"
" }"
)
failures += 0 if expect(
run_guard(write_fixture(workdir, "SyncOnReceiveRow.swift", sync_onreceive_row)),
False, "row .onReceive without .receive(on:) fails",
) else 1

# (p) The same subscription with a .receive(on: RunLoop.main) hop in
# the publisher chain passes -- also with the hop split across lines,
# since the real call sites chain one operator per line.
deferred_onreceive_row = row_fixture(
" HStack { Text(tab.title) }\n"
" .onReceive(\n"
" tabManager.selectedTabIdPublisher\n"
" .map { $0 == tab.id }\n"
" .removeDuplicates()\n"
" .receive(\n"
" on: RunLoop.main\n"
" )\n"
" ) { isSelected in\n"
" observedIsActive = isSelected\n"
" }"
)
failures += 0 if expect(
run_guard(write_fixture(workdir, "DeferredOnReceiveRow.swift", deferred_onreceive_row)),
True, "row .onReceive with .receive(on:) passes",
) else 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New cases (o)/(p) are inserted before the pre-existing case (n) in code order

In the file the execution sequence is (l) → (o) → (p) → (n), which breaks the alphabetical numbering that the rest of the test follows and that the module docstring lists. A reader tracing a failure number will find (n) after (o)/(p) in both the docstring and execution order but only if they know to look past them. Inserting the two new cases after (n) (or renaming them to follow the existing sequence) would preserve the invariant that the letter labels and the code order agree.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +529 to +533
if find_sync_onreceive(neutralized):
violations.append(
"row-wrapper file contains forbidden synchronous delivery: "
"{0}".format(ROW_SYNC_ONRECEIVE_MESSAGE)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 scan_all_rows branch of find_sync_onreceive has no meta-test coverage

Test cases (o) and (p) exercise find_sync_onreceive only through the type-body extraction path (a TabItemView struct is extracted and its body scanned). The scan_all_rows=True branch here — used for VerticalTabsSidebar+WorkspaceGroups.swift — calls find_sync_onreceive(neutralized) on the whole file, but no meta-test creates a wrapper-file fixture with a bare .onReceive( to verify that branch fires. Currently neither target file has any .onReceive calls, so the gap has no production impact today; if the wrapper file gains a synchronous subscription in future the guard would catch it, but only if this branch hasn't silently broken in the interim.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +323 to +343
# (o) An .onReceive( whose publisher chain has no .receive(on:) hop:
# the CurrentValueSubject bridge replays synchronously during lazy row
# realization and emits during willSet, so the action's @State write
# lands inside the in-flight layout transaction (the #2586/#6556
# family). This shape shipped in stable v0.64.17 and livelocked in the
# wild on 2026-07-02/03. A .receive(on:) inside the ACTION closure
# (outside the publisher argument) must not mask the violation.
sync_onreceive_row = row_fixture(
" HStack { Text(tab.title) }\n"
" .onReceive(\n"
" tabManager.selectedTabIdPublisher\n"
" .map { $0 == tab.id }\n"
" .removeDuplicates()\n"
" ) { isSelected in\n"
" observedIsActive = isSelected\n"
" }"
)
failures += 0 if expect(
run_guard(write_fixture(workdir, "SyncOnReceiveRow.swift", sync_onreceive_row)),
False, "row .onReceive without .receive(on:) fails",
) else 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test comment promises masking-via-action coverage that isn't in the fixture

The inline comment says "A .receive(on:) inside the ACTION closure (outside the publisher argument) must not mask the violation," but the sync_onreceive_row fixture's action body is observedIsActive = isSelected — no .receive(on:) in sight. The algorithm is correct by design (the trailing closure sits outside the balanced parentheses, so publisher_expr never includes it), but the stated property isn't actually exercised. A companion fixture with .receive(on:) only in the action would make this guarantee explicit and prevent a future algorithm refactor from silently regressing it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thanks for this! Sidebar rows are snapshot-driven now, so the onReceive livelock is gone landed on main in #8211. You opened this first, so you got there first. Closing since main covers it now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants